iT邦幫忙

2024 iThome 鐵人賽

DAY 8
2
Modern Web

為你自己寫 Vue Component系列 第 8

[為你自己寫 Vue Component] AtomicDropdown

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicDropdown

Dropdown 是一個用於顯示和選擇選項的 UI 元件,通常由一個觸發按鈕或文字組成,當使用者點擊該按鈕或文字時,會展開一個選單列表,列出多個選項供使用者選擇。選項可以是簡單的文字或帶有 Icon 的項目,使用者可以通過點擊選單中的選項來進行選擇,選擇後選單通常會自動收起。

<AtomicDropdown> 與之後會實作的 <AtomicSelect> 有一些差異。<AtomicDropdown> 是一個可以顯示彈出選單的元件,點擊選單裡面的選項可能會切換頁面,也可能會觸發像是新增、修改、刪除的操作;<AtomicSelect> 是一個用於選擇選項的元件,它通常用於表單的控制元件。換句話說,<AtomicDropdown> 是一個 UI (或 Navigation)元件,而 <AtomicSelect> 是一個表單元件。

在上一篇文章中,我們已經實作了 <AtomicPopover>,這一篇我們將以它為基礎,實作一個 <AtomicDropdown> 元件。

元件分析

元件架構

AtomicDropdown 元件架構

  1. Reference:觸發 Popover 的元素,可以是按鈕、文字或 Icon。
  2. Menu:選單,包含多個選項。
  3. MenuItem:選單的選項,可以是文字、Icon 或自定義內容。

功能設計

在開始實作前,我們先研究各個 UI Library 的 Dropdown 元件是如何設計的。

Element Plus

Element Plus Dropdown

<template>
  <ElDropdown trigger="click">
    <span>
      Dropdown List
      <ElIcon>
        <ArrowDown />
      </ElIcon>
    </span>
    <template #dropdown>
      <ElDropdownMenu>
        <ElDropdownItem>Action 1</ElDropdownItem>
        <ElDropdownItem>Action 2</ElDropdownItem>
        <ElDropdownItem>Action 3</ElDropdownItem>
        <ElDropdownItem disabled>Action 4</ElDropdownItem>
        <ElDropdownItem divided>Action 5</ElDropdownItem>
      </ElDropdownMenu>
    </template>
  </ElDropdown>
</template>

Element Plus 的 <ElDropdown> 使用方式大致與 <ElPopover> 相似,只是在 <ElDropdown> 另外提供了 <ElDropdownMenu><ElDropdownItem>,分別用來包裹選單與選項。

Vuetify

<template>
  <VMenu 
    :location="location" 
    offset="8" 
    open-on-hover
  >
    <template v-slot:activator="{ props }">
      <VBtn
        color="primary"
        v-bind="props"
      >
        Activator slot
      </VBtn>
    </template>
    <VList>
      <VListItem
        v-for="(item, index) in items"
        :key="index"
      >
        <VListItemTitle>{{ item.title }}</VListItemTitle>
      </VListItem>
    </VList>
  </VMenu>
</template>

在 Vuetify 中相似功能的元件叫做 <VMenu>,這在 <AtomicPopover> 的實作中已有提到。Vuetify 的 <VMenu> 也提供了 <VList><VListItem>,來分別包裹選單與選項。

Nuxt UI

Nuxt UI Dropdown

<template>
  <UDropdown :items="items" :popper="{ placement: 'bottom-start' }">
    <UButton color="white" label="Options" trailing-icon="i-heroicons-chevron-down-20-solid" />
  </UDropdown>
</template>

Nuxt UI 的 <UDropdown> 在使用上就單純很多,default slot 提供給開發人員自定義按鈕外觀,items 傳入一個二維陣列,提供選單選項。如果要設定 Menu 的定位則用 popper 設定。

相較於 Element Plus 與 Vuetify 的設計,Nuxt UI 的 <UDropdown> 使用方式是我比較偏好的,開發人員不需要使用各種不同的元件來包裹選單與選項,只需傳入一個選單資料即可。但在某些特殊樣式需求下,Element Plus 與 Vuetify 的設計有更高的靈活性。Nuxt UI 的設計若要達到這樣的彈性,必須盡可能地將各種可能性納入 <UDropdown> 元件內部實作,或是使用 slot 讓開發人員自定義每個項目。

綜合以上並結合自身經驗,我們統整出 <AtomicDropdown> 的功能:

  • 可以使用 default slot 提供觸發 Popover 的元素。
  • 可以透過 items 提供選單選項。
  • 可以透過 trigger 來決定 Popover 的觸發事件。
  • 可以透過 offset 來調整 Popover 的位置偏移。
  • 若有客製化選項需求,可以使用 menuitem slot 來自定義選項內容。

使用結構如下:

<template>
  <AtomicDropdown
    :items="items"
    :placement="placement"
    :trigger="trigger"
    :offset="offset"
  >
    <AtomicButton>Click to activate</AtomicButton>
  </AtomicDropdown>
</template>

元件實作

首先,我們將需求中提到的功能整理成 propsemit 的介面,我們會需要下列屬性:

名稱 型別 預設值 說明
items AtomicDropdownItem[] 控制 Menu 的顯示與否
placement top, right, bottom, left, top-start, top-end, right-start, right-end, bottom-start, bottom-end, left-start, left-end bottom-start Menu 的位置
trigger click, hover click 觸發 Menu 的事件
offset number, Partial<{ mainAxis: number; crossAxis: number; }> 8 Menu 與觸發元素的距離
disabled boolean false 是否禁用 Menu
type AtomicPopoverProps = ComponentProps<typeof AtomicPopover>;

interface AtomicDropdownItem {
  label: string;
  value: any;
  onClick?(value: any): void;
  disabled?: boolean;
}

interface AtomicDropdownProps {
  items: AtomicDropdownItem[];
  placement?: AtomicPopoverProps['placement'];
  offset?: AtomicPopoverProps['offset'];
  trigger?: 'click' | 'hover';
  disabled?: boolean;
}


const props = withDefaults(defineProps<AtomicDropdownProps>(), {
  items: () => [],
  placement: 'bottom-start',
  offset: 8,
});

<AtomicDropdown> 會自行管理開關的狀態,我們並不打算將 <AtomicDropdown> 設計為受控元件,因為絕大多數時候開發人員並不在意 <AtomicDropdown> 是否被開啟或關閉。

const active = ref(false);

我們先將 props.items 稍作整理,方便我們套用到模板上。

const itemsCompose = computed(() => {
  return props.items.map(item => {
    const onClick = () => {
      if (item.disabled || !isFunction(item.onClick)) return;
      item.onClick(item.value);
    };

    return {
      ...item,
      onClick,
    };
  });
});
<template>
  <AtomicPopover
    v-model="active"
    :disabled="disabled"
    :offset="offset"
    :placement="placement"
    :trigger="trigger"
  >
    <template #reference>
      <slot name="default" />
    </template>

    <ul class="atomic-dropdown">
      <li
        v-for="item in itemsCompose"
        :key="item.value"
        class="atomic-dropdown__menuitem"
        @click="item.onClick"
      >
        {{ item.label }}
      </li>
    </ul>
  </AtomicPopover>
</template>

到這裡已經可以使用 <AtomicDropdown> 元件了,但有個問題,點擊選項後無法控制 Menu 是否關閉。

為了在點擊選項後自動關閉 Menu,我們可以在 itemsonClick 裡加上一個 close 函式,用來關閉 Menu。

const itemsCompose = computed(() => {
  return props.items.map(item => {
    const onClick = () => {
      if (item.disabled || !isFunction(item.onClick)) return;
      item.onClick(item.value);
      active.value = false;
    };

    return {
      ...item,
      onClick,
    };
  });
});

不過這樣一來,使用者點擊選項後 Menu 必定會關閉,這樣的設計在某些情境下可能會造成限制,例如需求可能是想在點擊選項後執行一些操作,然後再手動關閉 Menu。

我們可以讓 itemsonClick 多接收第二個參數,這個參數是一個函式,當使用者點擊選項後,可以呼叫這個函式來關閉 Menu。

const onClick = (value: string, close: () => void) => {
  // Do something
  close();
};

這樣一來我們就要調整 itemsCompose 的實作。

const active = ref(false);
const close = () => (active.value = false);

const itemsCompose = computed(() => {
  return props.items.map(item => {
    const onClick = () => {
      if (item.disabled || !isFunction(item.onClick)) return;
      item.onClick(item.value, close);
    };

    return {
      ...item,
      onClick,
    };
  });
});

但如果大多數情境下只需點擊選項就關閉 Menu,開發人員還要每次都自己呼叫 close,未免太麻煩。我們可以參考 <AtomicTabs> 的做法,如果開發人員有使用到第二個參數,則認定開發人員想要自己控制是否關閉,如果沒有,則自動關閉 Menu。

為了實現這個功能,我們再次調整 itemsCompose 的實作。

const itemsCompose = computed(() => {
  return props.items.map(item => {
    const onClick = () => {
      if (item.disabled || !isFunction(item.onClick)) return;

      if (item.onClick.length <= 1) {
        item.onClick(item.value, noop);
        close();
        return;
      }

      return item.onClick(item.value, close);
    };

    return {
      ...item,
      onClick,
    };
  });
});

這樣如果開發人員傳入的 onClick 有接收第二個參數,元件就不會自動關閉,將控制權交給開發人員,反之則會自動關閉。

接著我們來處理客製化的需求,我們可能會遇到開發人員想在每個 MenuItem 上加上一些客製化的內容,這時我們可以使用 menuitem slot 來自定義選單的內容。

<template>
  <li
    v-for="item in itemsCompose"
    :key="item.value"
    class="atomic-dropdown__menuitem"
    @click="item.onClick"
  >
    <slot
      :disabled="item.disabled"
      :label="item.label"
      name="menuitem"
      :value="item.value"
    >
      {{ item.label }}
    </slot>
  </li>
</template>

這樣在畫面上可以這樣使用:

<AtomicDropdown :items="items">
  <button type="button">
    <MoreSvg />
  </button>

  <template #menuitem="{ label }">
    <div class="flex justify-between w-40">
      <span>{{ label }}</span>
    </div>
  </template> 
</AtomicDropdown>

在設計上,我們可以在 items 的每個物件上加上一個 context 屬性,並允許開發人員傳入任何他們想要使用的資料,這會有助於使用 slot 判斷哪個選項需要加入哪些客製化內容。

interface AtomicDropdownItem {
  // 略
  context?: any;
}
<li
  v-for="item in itemsCompose"
  :key="item.value"
  class="atomic-dropdown__menuitem"
  @click="item.onClick"
>
  <slot
    :context="item.context"
    :disabled="item.disabled"
    :label="item.label"
    name="menuitem"
    :value="item.value"
  >
    {{ item.label }}
  </slot>
</li>

這樣我們就完成了 <AtomicDropdown> 的實作。

AtomicDropdown

進階功能

鍵盤操作

如果可以使用鍵盤操作就更好了,在無障礙實作指南裡面有提到關於 Listbox 的 Pattern,我們可以參考這個指南來實作 <AtomicDropdown> 元件的鍵盤操作功能。

  • 當按下 Tab 會關閉 Menu。
  • 當按下 Down Arrow 焦點會往下一個 MenuItem 移動,如果在最後一個則移動到第一個。
  • 當按下 Up Arrow 焦點會往上一個 MenuItem 移動,如果在第一個則移動到最後一個。
  • 當按下 Home 焦點會移動到第一個 MenuItem。
  • 當按下 End 焦點會移動到最後一個 MenuItem。
  • 當按下 Enter 或 Space 則會執行該 MenuItem 上的點擊事件。
  • 當開啟 Menu 後,會自動將焦點移動到第一個 MenuItem。
  • 當關閉 Menu 後,會將焦點移回到觸發 Menu 的元素。

AtomicTabs 鍵盤操作

我們在實作 <AtomicTabs> 時有寫了 moveFocusnextItempreviousItem 這三個 function,在這裡我們可以重複利用這些 function。

import { moveFocus, nextItem, previousItem } from '~/utils/dom';

const onMenuKeydown = (event: KeyboardEvent) => {
  const container = menuRef.value as HTMLElement;
  const currentFocus = document.activeElement as HTMLElement;

  if (!container) return;

  switch (event.key) {
    case 'Tab':
      event.preventDefault();
      active.value = false;
      break;
    case 'ArrowDown':
      event.preventDefault();
      moveFocus(container, currentFocus, nextItem);
      break;
    case 'ArrowUp':
      event.preventDefault();
      moveFocus(container, currentFocus, previousItem);
      break;
    case 'Home':
      event.preventDefault();
      moveFocus(container, null, nextItem);
      break;
    case 'End':
      event.preventDefault();
      moveFocus(container, null, previousItem);
      break;
  }
};

我們將 onMenuKeydown 事件綁定在 Menu 上,這樣儘管使使用者聚焦在 MenuItem 上,我們也可以透過事件冒泡機制監聽到鍵盤事件。

<ul
  ref="menu"
  class="atomic-dropdown"
  @keydown="onMenuKeydown"
>
  <!-- 略 -->
</ul>

接著處理在 MenuItem 上按下 Enter 或 Space 的事件,這在 <AtomicButton> 內有提到過。在這裡,MenuItem 的 <li> 元素被視為按鈕使用,因此我們得讓它的行為與按鈕一致。

我們可以在 itemsCompose 裡擴充。

const itemsCompose = computed(() => {
  return props.items.map((item, index) => {
    const onClick = () => {
      // 略
    };

    const onKeydown = (event: KeyboardEvent) => {
      if (event.key !== 'Enter' && event.key !== ' ') return;
      event.preventDefault();

      onClick();
    };

    return {
      ...item,
      onClick,
      onKeydown,
    };
  });
});

並將事件綁定在 MenuItem 上。

<li
  v-for="item in itemsCompose"
  :key="item.value"
  class="atomic-dropdown__menuitem"
  @click="item.onClick"
  @keydown="item.onKeydown"
>
  <!-- 略 -->
</li>

到這裡,我們可以用 Up Arrow、Down Arrow、Home、End 來移動焦點,並且在 MenuItem 上按下 Enter 或 Space 來觸發點擊事件。

接著處理當開啟 Menu 時,將焦點自動移動到第一個 MenuItem。

我們需要先取得 Menu 的 Element,才能找到 Menu 裡的第一個 MenuItem。

<ul
  ref="menuRef"
  class="atomic-dropdown"
>
  <!-- 略 -->
</ul>

接著我們觀察 active 的變化,當 active 變為 true 時,我們就可以將焦點移動到第一個 MenuItem。

const menuRef = ref<HTMLElement>();

watch([active, menuRef] as const, ([isActive, menu]) => {
  if (!isActive || !menu) return;

  moveFocus(menu, null, nextItem);
});

最後處理當關閉 Menu 時,將焦點移回到觸發 Menu 的元素。

但這一步有個比較困難的問題,Reference 是由開發人員傳入的,我們要怎麼取得它呢?幸好我們在 <AtomicPopover> 中有類似的實作,可以重複利用這個 function。

import findFirstLegitChild from '~/helpers/findFirstLegitChild';

const referenceRef = ref<HTMLElement>();

const ReferenceComponent = defineComponent({
  name: 'ReferenceComponent',
  setup() {
    return () => {
      const child = findFirstLegitChild(slots.default?.());
      if (!child) return;

      return withDirectives(child, [
        [
          {
            mounted(el: HTMLElement) {
              referenceRef.value = el;
            },
            updated(el: HTMLElement) {
              referenceRef.value = el;
            },
            unmounted() {
              referenceRef.value = undefined;
            },
          },
        ],
      ]);
    }
  }
})

模板部分改使用 <ReferenceComponent>

<template>
  <AtomicPopover>
    <template #reference>
      <ReferenceComponent />
    </template>
    <!-- 略 -->
  </AtomicPopover>
</template>

這樣我們就可以取得 Reference 的 Element,並在關閉 Menu 時將焦點移回到 Reference 上。

watch([active, referenceRef] as const, ([isActive, reference]) => {
  if (isActive || !reference) return;

  reference.focus();
});

無障礙

前面實作的鍵盤操作部分已經涵蓋了無障礙的一部分,但我們還可以再加入一些設定來讓元件更符合無障礙標準。

角色 Role

在 HTML 元素上添加 role 屬性會讓網頁對於使用輔助技術(如螢幕閱讀器)的使用者更加友善。

在這裡我們要加上 menumenuitem 這兩個 role。

<ul
  class="atomic-dropdown"
  role="menu"
>
  <li
    v-for="item in itemsCompose"
    :key="item.value"
    class="atomic-dropdown__menuitem"
    role="menuitem"
  >
    <!-- 略 -->
  </li>
</ul>

ARIA 屬性

<AtomicPopover> 內部已經實作了 aria-controlsaria-expanded,在這裡我們只需要再加上 aria-haspopup 屬性即可。

aria-haspopup 可以接受的值有:menulistboxtreegriddialog 以及 true,其中 truemenu 的含義相同。

<template>
  <AtomicPopover v-model="active">
    <template #reference>
      <component 
        :is="ReferenceVNode"
        aria-haspopup="true"
      />
    </template>

    <!-- 略 -->
  </AtomicPopover>
</template>

總結

<AtomicDropdown> 的實作中,我們以 <AtomicPopover> 為基礎,實作了一個可以顯示彈出選單的元件。我們可以透過 items 來提供選單選項,並且還可以透過 menuitem slot 來自定義選項內容。

鍵盤操作部分我們參考了無障礙實作指南,並應用了在 <AtomicPopover><AtomicTabs><AtomicButton> 中實作與提到的概念,讓元件更加好用。也因為建立在這些元件的基礎與知識上,我們才能更有效率地完成這個元件。

參考資料


上一篇
[為你自己寫 Vue Component] AtomicPopover
下一篇
[為你自己寫 Vue Component] AtomicScrollbar
系列文
為你自己寫 Vue Component30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言